現今相當多的團隊都會使用框架開發,原因不外乎是因為框架提供了很多好用的工具可以加速開發,也能確保團隊可以在同一個規範下共同協作。當然還有許多其他的優點。
本文旨在介紹自製簡易框架的基本邏輯。雖然實務上,基本上不太可能會使用自製的框架來開發。然而,對於新手開發者而言,時常只是使用框架預先包裝好的工具開發,卻不明白背後的運作邏輯,要做深入的客製化更是難以執行。故本文透過自製簡易框架,來理解框架的基本運作邏輯。
本文介紹的自製框架採取 MVC 的架構,談及的觀念與程式碼絕大部分來自 Udemy 上的課程 Object Oriented PHP & MVC。本文通篇以解釋觀念為主,所以對部分程式碼進行簡化,甚至有些地方只有寫註解沒有撰寫實際上可以運行的程式。
讓我們從一個空的資料夾開始!我們首先建立以下的資料夾和檔案。
自製MVC框架/
├── app/
│ ├── config/
│ │ └── config.php
│ ├── libraries/
│ │ ├── Core.php
│ │ ├── Database.php
│ │ └── Controller.php
│ ├── models/
│ ├── views/
│ │ ├── inc/
│ │ │ ├── header.php
│ │ │ └── footer.php
│ │ └── pages/
│ │ └── index.php
│ ├── controllers/
│ │ └── Pages.php(預設的 Controller)
│ └── bootstrap.php
│
└── public/
├── index.php
├── css/
│ └── style.css(空檔案)
├── js/
│ └── main.js(空檔案)
└── img/
當使用者需要輸入的網址為:127.0.0.1/public 時,網站預設執行的位置是 public/index.php。它的程式碼只有短短兩行,只做兩件事,引入 bootstrap.php,然後將 Core 物件實例化(下一節會介紹到 Core)。
public/index.php
<?php
require_once '../app/bootstrap.php';
$init = new Core();
bootstrap.php 被引用進來之後,也只做兩件事,第一是引入 config.php 好讓程式可以取用裡面的全域變數,裡面包含一些關於資料庫環境變數或資源引用的路徑變數等等;第二是引用所有 libraries 裡面的檔案(下一節會介紹到 libraries)。
app/bootstrap.php
<?php
require_once 'config/config.php';
spl_autoload_register(function($className){
require_once 'libraries/' . $className . '.php';
});
app/config/config.php
<?php
// Database 的參數,以下為範例
define('DB_HOST', '127.0.0.1');
define('DB_NAME', 'default');
define('DB_USER', 'default');
define('DB_PASS', 'secret');
// App 根目錄,這是引入 app 資料夾裡的資源用的
define('APPROOT', dirname(dirname(__FILE__)) . '/');
// URL 根目錄,這是引入 public 資料夾裡的資源,或是頁面跳轉時用的
define('URLROOT', 'http://localhost:8000/public/');
// 網站名稱
define('SITENAME', '自製 MVC 框架');
libraries 是整個框架的核心,共有三支檔案:Core.php, Database.php, Controller.php。
Core.php 會在所有 request 發生的時候被實例化,然後進行路由處理。
Core 物件被實例化的過程中,簡單來說做了以下幾件事:
app/libraries/Core.php
<?php
class Core
{
// 預設 Controller 為 Pages
protected $currentController = 'Pages';
// 預設方法為 index
protected $currentMethod = 'index';
// 預設參數為空
protected $params = [];
public function __construct()
{
// 呼叫 getUrl() 取得 $url 陣列
// 將 $url[0] 視為 Controller 的名稱
// 檢查 $url[0] 是否有對應的 Controller ,即是否存在 $url[0].php 的檔案
if(存在)
$currentController = $url[0];
// 引入 Controller
// 實例化 Controller
// $url[1] 視為 Controller 中的方法
// 所以先要檢查是否有值,若有,檢查該值是否有對應的方法
if(isset($url[1]))
if(method_exists($this->currentController, $url[1]))
$this->currentMethod = $url[1];
// $url 陣列中的第三個值開始,視為帶入方法中的參數
// 用 $params 陣列儲存所有剩下的值
// 最後透過呼叫 callback 來執行方法
call_user_func_array([$this->currentController, $this->currentMethod], $this->params);
}
public function getUrl()
{
// 從 public?url= 後開始,將 $url 按 / 切分,轉換成陣列並回傳
// 例如: 使用者輸入 127.0.0.1/public?url=posts/show/1
// 則回傳 $url 的值為 ['posts', 'show', 1]
// 它將在 __construct() 中依序被解析成 Controller, 方法, 參數
}
}
Database.php 比較單純。簡述如下:
app/libraries/Database.php
<?php
class Database
{
// 設定資料庫的常數來自於 config/config.php
private $host = DB_HOST;
private $dbname = DB_NAME;
private $user = DB_USER;
private $pass = DB_PASS;
// 定義一些操作 Database 的變數,例如:
private $dbh;
private $stmt;
private $error;
public function __construct()
{
// 透過 PDO 建立資料庫連線
// 實例化 PDO
}
// Prepare statement with query
public function query($query){...}
// Bind values
public function bind($param, $value, $type = null){...}
// 執行 prepared statement
public function execute(){...}
// 以下是 Model 可以操作資料庫的幾個預設方法
// 可以自行定義更多需要的或常用的
// 取得資料表的所有資料
public function getAll(){...}
// 取得資料表的單一筆資料
public function getSingle(){...}
// 取得資料表中資料的筆數
public function getRowCount(){...}
}
最後是 Controller.php。它只提供兩個方法,分別用來載入 Model 和載入 View。
所有其他的 Controller 都要繼承 Controller.php。這讓我們在自定義的 Controller 中可以輕鬆的建立 Model 物件操作對應的資料庫,並且將回傳的值(如果有的話)包進陣列塞到 view 裡面呈現在網頁上。
app/libraries/Controller.php
<?php
class Controller
{
// 載入 model
public function model($model)
{
require_once '../app/models/' . $model . '.php';
return new $model();
}
// 載入 view
// 其中 view 可能有需要從 Controller 帶過去的資料,故多了 $data 陣列作為第二個參數
public function view($view, array $data = [])
{
// 如果檔案存在就引入它
if(file_exists('../app/views/' . $view . '.php')){
require_once '../app/views/' . $view . '.php';
} else {
die('View does not exist');
}
}
}
我們在 views 裡面建立一個 inc 的資料夾(include 的縮寫),並且在裡面建立 header.php 和 footer.php 兩支檔案,來定義好基本 html 架構和引用 css, js 資源。所有其他的 view 都將引入這兩支檔案,來減少撰寫重複的程式碼。
app/views/inc/header.php
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="<?php echo URLROOT; ?>/css/style.css">
<title><?php echo SITENAME; ?></title>
</head>
<body>
app/views/inc/footer.php
<script src="<?php echo URLROOT; ?>/js/main.js"></script>
</body>
</html>
還記得我們在 app/libraries/Core.php 中定義預設 Controller 為 Pages,且預設方法為 index 嗎?所以我們要預先定義好 app/controllers/Pages.php 這支檔案,還有 app/views/pages/index.php 頁面。
app/controllers/Pages.php
<?php
Class Pages extends Controller
{
public function __construct()
{
// 當 Controller 需要操作資料庫時,這裡可以實例化該 Model。
// 不過這裡我們只是要單純引入 view,所以 __construct 裡不需要撰寫任何程式碼。
}
public function index()
{
// 這裡可以引入頁面,我們即將在 views 資料夾底下建立一個 pages/index.php 的檔案,故可以先寫好以下的程式碼:
$this->view('pages/index');
}
}
在預設的頁面裡,我們單純引入上一節的 header.php 跟 footer.php,然後在畫面上印出 HELLO WORLD!。
app/views/pages/index.php
<?php require APPROOT . 'views/inc/header.php'; ?>
<h1>HELLO WORLD!</h1>
<?php require APPROOT . 'views/inc/footer.php'; ?>
以 127.0.0.1/public?url=pages/index 為例,運作流程如下:
到這邊,我們已經完成框架了。接下來說明如何基於這個我們自製的框架進行開發。
假設我們要開發一個可以發文的相關功能,包含查看全部、新增、查看一筆、修改、刪除等五個功能。
這個框架符合 MVC 的架構,而且有發文的相關需求肯定需要和資料庫溝通,所以我們可以確定需要建立的檔案將會包含 Model、View、Controller。
我們先來談談 Model。這邊我們建立一個 Post.php,當 Post 物件被實例化時,會透過建構子將 Database 物件實例化,也就是說,它將自動連線完畢並且能夠使用我們剛剛在 Database.php 裡面定義的那些可以操作資料庫的基本方法!
接著我們在 Post 物件裡繼續撰寫需要用到的方法,包含「取得所有文章」、「發佈新文章」、「取得特定一則文章」、「更新文章」、「刪除文章」。這些方法都是根據基本方法作延伸的。
app/models/Post.php
<?php
class Post{
private $db;
// 在建構子將 Database 物件實例化
public function __construct()
{
$this->db = new Database;
}
// 取得所有文章
public function getPosts()
{
$query = 'SELECT ...';
$this->db->query($query);
$results = $this->db->getAll();
return $results;
}
// 發佈新文章
public function storePost($data)
{
$query = 'INSERT ...';
$this->db->query($query);
$this->db->bind('title', $data['title']);
$this->db->bind('body', $data['body']);
if($this->db->execute()){
return true;
} else{
return false;
}
}
// 取得特定一則文章
public function getPostById($id)
{
$query = 'SELECT ...';
$this->db->query($query);
$this->db->bind('id', $id);
$result = $this->db->getSingle();
return $result;
}
// 更新文章
public function updatePost($data)
{
$query = 'UPDATE ...';
$this->db->query($query);
$this->db->bind('id', $data['id']);
$this->db->bind('title', $data['title']);
$this->db->bind('body', $data['body']);
if($this->db->execute()){
return true;
} else{
return false;
}
}
// 刪除文章
public function deletePost($id){
$query = 'DELETE ...';
$this->db->query($query);
$this->db->bind('id', $id);
if($this->db->execute()){
return true;
} else{
return false;
}
}
}
接著是 Controller。這次我們需要操作資料庫,所以在建構子中呼叫 model('Post') 並賦值給 postModel。這個方法來自於它繼承的 Controller.php,用意是將 Model Post 實例化。又因為上面我們知道 Post 被實例化時會將 Database 實例化,且擁有五個操作文章的方法。所以接下來我們就可以使用 postModel 執行這些方法。
再來,我們再增添五個方法:「index」、「create」、「show」、「edit」、「delete」。在這些方法中透過 postModel 對資料庫做操作,操作完畢後呼叫 view() 來呈現畫面。若有回傳值則將回傳值塞進 view() 的第二個參數,若無則僅需一個參數。稍微不同的是,由於 delete() 的目的是要刪除某一筆文章,所以沒有對應的 view,而是重新導向回 posts/index。又因為我們在 Core.php 裡定義預設的方法為 index,所以僅需重新導向回 posts 即可。
app/controllers/Posts.php
<?php
class Posts extends Controller
{
// 在建構子中將 Post 物件(Model)實例化
public function __construct()
{
$this->postModel = $this->model('Post');
}
// 取得所有文章
public function index(){
$posts = $this->postModel->getPosts();
$data = [
'posts' => $posts
];
$this->view('posts/index', $data);
}
// 發佈新文章
public function create(){
// 註:基於輸入值得驗證及安全性,需要對使用者的 post 資料做處理。
// 但是這裡省略上述步驟,以觀念解釋為主。
$data = [
'title' => $_POST['title'],
'body' => $_POST['body'],
];
$this->view('posts/create', $data);
}
// 取得特定一則文章
public function show($id)
{
$post = $this->postModel->getPostById($id);
$data = [
'post' => $post,
'user' => $user
];
$this->view('posts/show', $data);
}
// 更新文章
public function edit($id){
// 註:這裡跟新增文章相當類似
// 一樣省略驗證與消毒,以觀念解釋為主。
$data = [
'title' => trim($_POST['title']),
'body' => trim($_POST['body']),
];
$this->view('posts/edit', $data);
}
// 刪除文章
public function delete($id)
{
// 註:這裡一樣省略驗證與消毒,以觀念解釋為主。
$this->postModel->deletePost($id)
// 這邊預先寫了一個全域函式,可以重新導向
redirect('posts');
}
}
最後是在 views 要加入幾個對應的檔案。因為刪除並沒有對應的頁面,所以只需要新增前面四個頁面。
app/views/posts/index.php
app/views/posts/create.php
app/views/posts/show.php
app/views/posts/edit.php
以上,我們完成發文相關功能的實作。我們最後再舉一個例子幫助大家複習整個運作流程。
本文介紹的觀念與程式碼絕大部分來自 Udemy 上的課程。以下附上課程連結:
Object Oriented PHP & MVC